Skip to content

S03-08 核心类-日期时间

[TOC]

概述

UTC vs GMT

在日常生活中,当我们提到“世界时间”时,通常是指协调世界时(UTC,Coordinated Universal Time)。它是目前全球统一使用的时间标准,世界上所有国家和地区的本地时间都是以它为基准推算出来的。

  • UTC(协调世界时,Coordinated Universal Time):这是现代科学领域的时间基准。它主要依靠分布在全球的几百台极度精确的“原子钟”来计时,同时还会根据地球自转速度的微小变化(通过引入“闰秒”)进行微调,以确保时间既精确又符合地球的昼夜规律。

  • GMT(格林尼治标准时间,Greenwich Mean Time):这是历史上的世界时间基准。它是通过观测太阳穿过英国伦敦格林尼治天文台本初子午线(经度为 0 度)的时刻计算出来的。虽然在日常交流或计算机系统中,GMT 常被当做 UTC 的同义词,但在严谨的科学和航空领域,UTC 已经完全取代了 GMT。

  • GMT 是前世界标准时,而 UTC 是现世界标准时。 UTC 比 GMT 更加精准。UTC 已经完全取代了 GMT。

TAI vs UT1

我们现在定义时间的标准,其实有两套体系在“打架”:

  • 原子时(TAI):这是极其精准的物理时间,基于原子内部的震荡频率计算。它极其稳定,几千万年都不会误差一秒。

  • 世界时(UT1):这是基于地球自转的“天文时间”,也就是我们常说的“日出而作,日落而息”。

闰秒

闰秒 就是为了让“人类的手表”和“地球的自转”保持同步,而人为增加(或减少)的那一秒钟


为什么需要闰秒

由于地球自转是不均匀的,且总体趋势是在慢慢变慢。月球引发的潮汐摩擦力、内部岩浆流动、大地震、甚至极地冰川融化,都会导致地球自转一圈的时间稍微变长。

如果人类完全按照绝对精准的“原子时”生活,几千年后,中午 12 点的时候,太阳可能才刚刚从地平线升起。为了让精准的“钟表时间”(也就是目前全球通用的协调世界时 UTC)去迁就稍微有些不准的“地球自转”,国际地球自转服务组织(IERS)就发明了“闰秒”。

当这两个时间的误差累积快达到 0.9 秒时,专家们就会宣布:我们要强行加(或减)一秒了!


闰秒是怎么加的

通常,闰秒会安排在 6 月 30 日或 12 月 31 日的最后一秒。

  • 正常的时钟跳动是这样的:23:59:58 -> 23:59:59 -> 00:00:00

  • 加上闰秒的那天,时钟会变成这样:23:59:58 -> 23:59:59 -> 23:59:60 -> 00:00:00

也就是说,那一天的最后一分钟有 61 秒。从 1972 年首次引入到今天,全球一共增加了 27 次正闰秒(由于地球偶尔也会转得快一点,理论上也有“负闰秒”,但历史上还从未实施过)。

夏令时

夏令时(DST,Daylight Saving Time,日光节约时制)在天亮得早的夏季,人为地把时钟“拨快”,让人早起早睡;等到了秋冬季节,再把时钟“拨回”正常时间

它是一种为了节约能源、充分利用自然光照而人为调整地方时间的制度。


夏令时是怎么运行的:

国际上通用的夏令时调整口诀是 “Spring Forward, Fall Back”(春前秋后)

  • 春季(拨快 1 小时):当春天到来,白天变长时,在规定的某一天凌晨(通常是周末),把时钟从 01:59 直接拨到 03:00。这会让你那天少睡一个小时,但随后的每一天,你都会比平时“早”一个小时起床,从而在傍晚享受到更多的日光。
  • 秋季(拨回 1 小时):当秋天到来,白天变短时,在规定的某一天凌晨,把时钟从 01:59 拨回 01:00。这天你会多出一个小时的睡眠,时间重新恢复为标准时间(冬令时)。

时区

时区(Time Zone):是地球上使用同一个标准时间的区域。


为什么要发明时区

在古代,人们靠看太阳来定时间。太阳升到最高点就是正午 12 点(这叫地方太阳时)。

但地球是圆的,并且一直在自转。这就导致当北京是正午时,往西边走的乌鲁木齐太阳才刚刚升起,而往东边的日本太阳已经偏西了。

在马车时代,这种误差无伤大雅。但到了 19 世纪,铁路和电报普及了。如果每个城市都用自己的太阳时,火车的时刻表会彻底变成一场灾难(比如从 A 城早上 8 点出发,坐了 1 小时车到 B 城,结果 B 城的时间才 7 点半)。

为了统一标准,让跨地区协作成为可能,人类在 1884 年的国际会议上正式确立了“时区”的概念。


时区是如何划分的

理论上的时区划分非常像切西瓜:

  1. 基本数学: 地球自转一圈是 360 度,耗时 24 小时。所以 360 ÷ 24 = 15 度。这意味着,经度每相差 15 度,时间就相差 1 个小时。

  2. 零点基准: 科学家们把经过英国伦敦格林尼治天文台的那条经线定为“本初子午线”(0 度经线)。以此为中心,划出了中时区(也就是零时区,对应 UTC/GMT 时间)

  3. 东西推算:本地时间 = 世界时间(UTC) + 时区偏移量

    • 从零时区向东,每跨越 15 度就是一个新时区,时间加 1 小时(东一区 UTC+1,东二区 UTC+2...直到东十二区)。
    • 从零时区向西,时间减 1 小时(西一区 UTC-1,西二区 UTC-2...直到西十二区)。
    • 东西十二区在太平洋中部重合,这里有一条人为划分的国际日期变更线

现实中的“妥协”与“奇葩”

虽然理论上的时区是一条条笔直的经线,但现实中,为了方便管理,时区的划分充满了政治和行政的妥协:

  • 弯弯曲曲的边界线: 时区线通常会避开把一个城市或一个小国劈成两半,而是顺着国界或州界弯曲。

  • 大国的不同选择:

    • 美国、俄罗斯、澳大利亚等大国,因为东西跨度太大,国内实行多时区制。比如美国本土就有东部、中部、山地、太平洋四个主时区。
    • 中国的地理位置横跨了从东五区到东九区五个时区,但为了国家行政和调度的统一,全国统一采用东八区的北京时间(UTC+8)作为标准时间。
  • 奇葩的零头时区: 并不是所有时区都是整点相差。有些国家为了让本国时间更贴近太阳的真实起落,采用了半小时甚至 15 分钟的偏移量。比如印度统一使用 UTC+5:30,而尼泊尔使用的是极其罕见的 UTC+5:45

JDK8 之前

在 Java 8 引入全新的 java.time 包之前,Java 主要依靠 java.util.Datejava.util.Calendar 以及 java.text.SimpleDateFormat 来处理日期和时间。

尽管这些旧版 API 存在许多设计缺陷,如今已被标记为“不推荐使用”,但在维护老旧系统、阅读遗留代码或与早期的第三方库(如旧版 JDBC、某些 JSON 序列化库)对接时,深入理解这些类依然是 Java 开发者的必修课。

System.currentTimeMillis()

  • static long currentTimeMillis()()时间戳,返回从 1970年1月1日 00:00:00 UTC 到当前时刻所经过的毫秒数
    用途:常用于计算代码执行耗时,或者作为生成唯一 ID 的因子

    java
    long timestamp = System.currentTimeMillis();
    System.out.println("当前时间戳: " + timestamp);
  • 计算世界时间的主要标准有:

    • UTC(Coordinated Universal Time)
    • GMT(Greenwich Mean Time)
    • CST(Central Standard Time)

    在国际无线电通信场合,为了统一起见,使用一个统一的时间,称为通用协调时(UTC, Universal Time Coordinated)。UTC与格林尼治平均时(GMT, Greenwich Mean Time)一样,都与英国伦敦的本地时相同。这里,UTC与GMT含义完全相同。

java.util.Date

在 Java 的历史长河中,java.util.Date 是最古老的处理日期和时间的类(自 JDK 1.0 引入)。尽管在现代 Java 开发中它已经被 java.time 包下的新 API 取代,但由于历史包袱,我们在维护老项目、使用旧版数据库驱动或某些遗留的第三方库时,依然会频繁地和它打交道。

时间戳的包装类

许多初学者会被 Date 的名字欺骗,认为它代表着日历上的某一天(包含时区等信息)。但实际上,Date 内部只维护了一个简单的 long 类型变量

  • 唯一状态变量:这个 long 值叫做 fastTime,它记录的是从 Unix 纪元(1970 年 1 月 1 日 00:00:00 GMT)到当前对象所表示的时间之间,所经过的毫秒数
  • 没有时区概念Date 对象本身绝对不包含任何时区信息。无论你在北京、纽约还是伦敦创建了一个表示同一瞬间的 Date 对象,它们内部的 long 值都是完全相同的。

那为什么打印时会有时区?

当你调用 System.out.println(new Date()) 时,实际上是调用了 Date.toString() 方法。这个方法会在内部抓取你操作系统的默认时区,然后把这个底层的时间戳翻译成你当地的时间并拼接成字符串(例如 Tue May 12 15:19:34 CST 2026)。时区只是“显示”时的魔法,而不是对象内部的属性。

API:Date

目前,Date 类中绝大多数方法都已经被标记为 @Deprecated(已过时),只有以下几个与时间戳直接交互的方法还在正常使用:

构造方法

目前官方推荐使用的构造方法只有两个:

  • Date()(),分配一个 Date 对象,并用当前时间(精确到毫秒)对其进行初始化。
    本质:底层实际上是调用了 System.currentTimeMillis()

    java
    // 1. 获取当前时间的 Date
    Date now = new Date();
  • Date()(long date),分配一个 Date 对象,并根据给定的毫秒时间戳进行初始化。

    java
    // 2. 根据时间戳创建 Date (例如:2023-01-01 00:00:00 UTC 的时间戳)
    long timestamp = 1672531200000L;
    Date specificDate = new Date(timestamp);
获取与设置

Date 对象内部唯一维护的状态就是一个 long 类型的时间戳,以下两个方法直接操作这个底层数据:

  • long getTime()()获取Date 对象所表示的毫秒级时间戳
    场景:将 Date 存入数据库的长整型字段,或者参与数学计算时非常常用。
  • void setTime()(long time),将该 Date 对象内部的时间戳设置为指定的值。
    注意:这个方法使得 Date 成为一个可变对象 (Mutable)。在多线程或安全敏感的场景下,轻易调用 setTime 会导致意外的 Bug。
java
Date date = new Date();
// 获取时间戳
long currentMillis = date.getTime();

// 将时间往后推移 1 小时 (1小时 = 3600000 毫秒)
date.setTime(currentMillis + 3600000);
时间比较

在业务逻辑中,经常需要判断两个时间的先后顺序,Date 提供了四个非常便捷的方法:

  • boolean before()(Date when),测试此日期是否在指定日期 when 之前

  • boolean after()(Date when),测试此日期是否在指定日期 when 之后

  • int compareTo()(Date anotherDate)比较两个日期的顺序(实现了 Comparable 接口)。
    如果参数 Date 等于此 Date,则返回值 0
    如果此 Date 在参数 Date 之前,则返回 < 0 的值;
    如果此 Date 在参数 Date 之后,则返回 > 0 的值。

  • boolean equals()(Object obj)比较两个日期的相等性
    只有当两个 Date 对象的 getTime() 返回的毫秒数完全相同时,才返回 true

java
Date past = new Date(System.currentTimeMillis() - 10000); // 10秒前
Date now = new Date();

System.out.println("now 在 past 之后吗? " + now.after(past));   // true
System.out.println("now 在 past 之前吗? " + now.before(past));  // false
System.out.println("比较结果: " + now.compareTo(past));          // 1 (因为 now > past)
现代转换

为了让老旧的 Date 能够无缝接入 Java 8 的全新时间类(java.time 包),官方在 Java 8 中为 Date 新增了一个极其重要的方法:

  • Instant toInstant()()JDK8,将此 Date 对象转换为一个 Instant 对象(时间戳的现代面向对象表示)。
    场景:这是旧代码与新代码之间的黄金桥梁。只要拿到了 Date,第一时间用这个方法把它变成 Instant,然后就可以配合 ZoneId 轻松转为 LocalDateTime 等新版类。

    java
    Date legacyDate = new Date();
    
    // 核心桥接操作:Date -> Instant
    Instant instant = legacyDate.toInstant();
    
    // 然后就可以使用现代 API 了 (例如转为本地时间)
    LocalDateTime modernDateTime = instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
输出方法
  • String toString()(),将 Date 对象转换为形如 dow mon dd hh:mm:ss zzz yyyy 的字符串(例如 Tue May 12 15:33:24 CST 2026)。
    注意:这是 Date 类内部唯一涉及时区计算的地方。它会读取操作系统的默认时区来格式化字符串。不要依赖这个方法来做数据传输,它仅适用于控制台打印调试。如果需要特定格式的字符串,必须配合 SimpleDateFormat(注意线程安全)使用。

设计缺陷

Sun 公司在 JDK 1.1 时就意识到了 Date 的问题,并试图用 Calendar 来补救,但直到 Java 8 才真正彻底解决。

Date 的主要缺陷如下:

  1. 命名与职责名不副实

    名字叫 Date(日期),但它不仅包含年月日,还包含时分秒和毫秒。这导致在只需要传递“某一天”(如生日、节假日)的业务场景中,不得不想办法抹去时分秒(通常设为 00:00:00),极其麻烦。

  2. 反人类的偏移量(已过时方法)

    如果你尝试使用它那些已过时的构造函数或 Getter 方法,你会发现非常违背直觉的规则:

    • 年份:是从 1900 年开始计算的。如果你想表示 2023 年,你传入的参数必须是 123(2023 - 1900)。调用 getYear() 返回的也是 123。
    • 月份:是从 0 开始的。0 代表一月,11 代表十二月。这导致了无数的 "差一个月" Bug。
    • 天数:更奇葩的是,月份从 0 开始,但一个月中的哪一天(getDate())却是从 1 开始的。
    java
    // 极其反人类的旧版构造方式 (已被强烈废弃)
    // 意图创建: 2023年10月25日
    Date badDate = new Date(123, 9, 25); // 123代表2023, 9代表10月
  3. 可变性 (Mutability)

    Date 提供了 setTime(long time) 方法。这意味着当你把一个 Date 对象作为参数传递给某个方法后,那个方法可以在内部偷偷修改这个时间!

    在安全要求高的场景(如密码过期时间验证、防重放攻击校验),必须在每次 getset 时进行防御性拷贝(克隆一个新对象),否则会引发严重的安全隐患。

  4. 缺乏直接的格式化与计算能力

    Date 自己不能格式化,必须配合 SimpleDateFormat(线程不安全)使用;它自己也不能进行“加一天”、“减三个月”的运算,必须配合 Calendar(极其繁琐)使用。

遗留 Date 处理

如何优雅地处理遗留的 Date(与 Java 8+ 桥接):

在现代开发中,如果你从老接口或者旧版数据库驱动中拿到了一个 Date 对象,最佳实践是立即将其转换为 Java 8 的新 API,进行业务逻辑处理,如果必须要返回 Date 给老接口,再转换回去。

桥接的核心是 Instant(时间戳对象)

Instant toInstant()()JDK8,将此 Date 对象转换为一个 Instant 对象(时间戳的现代面向对象表示)。
场景:这是旧代码与新代码之间的黄金桥梁。只要拿到了 Date,第一时间用这个方法把它变成 Instant,然后就可以配合 ZoneId 轻松转为 LocalDateTime 等新版类。

java
import java.util.Date;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.Instant;

public class DateConverter {

  // 1. 旧版 Date -> 新版 LocalDateTime
  public static LocalDateTime dateToLocalDateTime(Date date) {
    // 先转成绝对瞬间 Instant,再结合系统默认时区转成 LocalDateTime
    return date.toInstant()
      .atZone(ZoneId.systemDefault())
      .toLocalDateTime();
  }

  // 2. 新版 LocalDateTime -> 旧版 Date
  public static Date localDateTimeToDate(LocalDateTime localDateTime) {
    // 先结合系统默认时区转成 ZonedDateTime,再转成 Instant,最后装入 Date
    Instant instant = localDateTime.atZone(ZoneId.systemDefault()).toInstant();
    return Date.from(instant);
  }
}

总结:将 java.util.Date 视为一个只用来装载 long 类型时间戳的“老旧快递盒”。业务逻辑运算和格式化,统统交给拆箱后的 java.time 包去处理即可。

练习题

练习:如何将一个 java.util.Date 的实例转换为 java.sql.Date 的实例

image-20260513180552901


练习:将控制台输入的年月日(如 2022-12-13)字符串数据保存到数据库中(转换为 java.sql.Date 对象)

image-20260513181106024

java.text.SimpleDateFormat

在 Java 的日期时间处理历史中,java.text.SimpleDateFormat 扮演了极其重要但也最具争议的角色。它是 Java 8 之前用于格式化(将对象转为字符串)解析(将字符串转为对象) 日期时间的核心基石。

尽管它存在致命的线程安全问题,但在维护老旧项目时,你依然会随处可见它的身影。

格式化与解析

SimpleDateFormatDateFormat 的一个具体子类。它的主要职责是在 java.util.Date 对象和 String 字符串之间建立桥梁:

  1. 格式化 (Formatting)Date -> String。把机器友好的时间戳对象,转换成人类可读的指定格式字符串(例如 "2023-10-25")。

  2. 解析 (Parsing)String -> Date。把人类输入的带有格式的时间字符串,转换为程序可以处理的 Date 对象。

占位符字母表

使用 SimpleDateFormat 时,你必须定义一个“模式字符串”(Pattern)。这个字符串由特定的英文字母组成,大小写极其严格。以下是最常用的占位符:

字母代表含义示例表现
y年份 (Year)yyyy -> 2023; yy -> 23
M月份 (Month)MM -> 09 (补零); M -> 9; MMM -> Sep
d月中的天数 (Day in month)dd -> 05 (补零); d -> 5
H24 小时制 (0-23)HH -> 14
h12 小时制 (1-12)hh -> 02
m分钟 (Minute)mm -> 30
s秒 (Second)ss -> 59
S毫秒 (Millisecond)SSS -> 892
E星期几 (Day of week)E -> 星期二 / Tue
Z时区(TimeZone)Z -> -0800

最经典的组合模式"yyyy-MM-dd HH:mm:ss" (例如:2023-10-25 14:30:00)

API:SimpleDateFormat

构造方法

通常我们在创建实例时,不仅要指定格式,有时候还需要指定语言环境 (Locale),这对于解析包含英文字母的月份或星期极其重要。

  • SimpleDateFormat()(String pattern),使用指定的模式字符串和系统默认的语言环境(Locale)构造。

    java
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
    Date date = sdf.parse(dateStr);
  • SimpleDateFormat()(String pattern, Locale locale),使用指定的模式字符串和指定的语言环境构造。
    避坑场景:如果你的服务器操作系统是中文环境,但你需要解析 "05/Sep/2023" 这样的英文格式字符串,只传 pattern 会报错,必须指定 Locale.USLocale.ENGLISH

    java
    // 如果系统是中文环境,直接解析英文月份会抛出 ParseException
    String dateStr = "25/Oct/2023";
    
    // 正确做法:显式指定英语 Locale
    SimpleDateFormat sdf = new SimpleDateFormat("dd/MMM/yyyy", Locale.ENGLISH);
    Date date = sdf.parse(dateStr);
格式化与解析
  • format()(Date date),将给定的 Date 对象格式化为符合模式的字符串。

    java
    import java.text.SimpleDateFormat;
    import java.util.Date;
    
    public class FormatExample {
        public static void main(String[] args) {
            Date now = new Date();
    
            // 模式 1: 标准完整格式
            SimpleDateFormat sdf1 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            System.out.println(sdf1.format(now)); // 2026-05-12 16:15:30
    
            // 模式 2: 中文格式带毫秒
            SimpleDateFormat sdf2 = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss.SSS");
            System.out.println(sdf2.format(now)); // 2026年05月12日 16:15:30.123
        }
    }
  • parse()(String source)尝试解析给定的字符串,生成一个 Date 对象。
    异常:如果字符串内容与模式不匹配,会抛出 ParseException(这是一个受检异常,必须 try-catchthrows)。

    java
    import java.text.ParseException;
    import java.text.SimpleDateFormat;
    import java.util.Date;
    
    public class ParseExample {
        public static void main(String[] args) {
            String dateString = "2023-01-01 12:00:00";
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    
            try {
                // 将字符串解析为 Date 对象
                Date parsedDate = sdf.parse(dateString);
                System.out.println("解析成功的时间戳: " + parsedDate.getTime());
            } catch (ParseException e) {
                System.err.println("字符串格式不符合预期的 yyyy-MM-dd HH:mm:ss");
                e.printStackTrace();
            }
        }
    }

线程不安全

这是 Java 面试中几乎必问的经典问题:SimpleDateFormat 是线程不安全的。

为什么不安全:

如果你查看 SimpleDateFormat 的底层源码,会发现它继承自 DateFormat。在 DateFormat 内部,维护了一个实例变量 protected Calendar calendar;

当调用 format()parse() 时,SimpleDateFormat 会先将时间设置到这个全局的 calendar 中,然后进行下一步操作。

如果多线程共享同一个 SimpleDateFormat 实例(例如把它定义为 public static final),线程 A 刚把时间设进 calendar,还没来得及转成字符串,就被线程 B 把 calendar 的时间给覆盖了。这会导致抛出 NumberFormatException 或者返回极其混乱、错误的时间。

经典的错误示范:

java
// ⚠️ 1. 危险!绝对不要在多线程环境中这样写全局共享变量
public static final SimpleDateFormat ERROR_SDF = new SimpleDateFormat("yyyy-MM-dd");

public void doTask(Date date) {
  // 2. 多线程并发调用时,必然报错或数据错乱
  String str = ERROR_SDF.format(date);
}

旧代码并发解决方案

如果在维护老项目,必须使用 SimpleDateFormat,有以下三种解决方式:

方案 1:局部变量法(简单,但性能最差)

每次需要格式化时都 new 一个新对象。这会导致频繁创建和销毁对象,增加垃圾回收(GC)的压力。

java
public String formatDate(Date date) {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
    return sdf.format(date);
}

方案 2:同步锁加锁法(安全,但并发性能差)

对共享的实例加锁,强行让多线程排队执行。

java
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd");

public static synchronized String formatSafe(Date date) {
    return SDF.format(date);
}

方案 3:ThreadLocal(旧版代码的最佳实践,推荐 ⭐)

利用 ThreadLocal 为每一个线程绑定一个独立的 SimpleDateFormat 实例,做到“空间换时间”,既保证了线程安全,又避免了频繁创建对象。

java
public class DateUtils {
    // 为每个线程分配独立的 SimpleDateFormat 实例
    private static final ThreadLocal<SimpleDateFormat> SDF_THREAD_LOCAL =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

    public static String formatSafe(Date date) {
        return SDF_THREAD_LOCAL.get().format(date);
    }

    public static Date parseSafe(String dateStr) throws ParseException {
        return SDF_THREAD_LOCAL.get().parse(dateStr);
    }
}

现代平替:DateTimeFormatter

自从 Java 8 推出后,你不再需要(也不应该)在新项目中使用 SimpleDateFormat

Java 8 引入了 java.time.format.DateTimeFormatter。它被设计为绝对不可变且线程安全的,可以放心地定义为全局静态变量。

java
// Java 8 的现代做法:线程安全,直接设为全局常量
public static final DateTimeFormatter MODERN_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

public static void main(String[] args) {
    LocalDateTime now = LocalDateTime.now();
    // 格式化
    String str = now.format(MODERN_FORMATTER);

    // 解析
    LocalDateTime parsed = LocalDateTime.parse("2023-10-25 12:00:00", MODERN_FORMATTER);
}

java.util.Calendar

在 Java 发展的早期(JDK 1.1),为了解决 java.util.Date 无法处理国际化、缺乏时区支持以及难以进行日期运算(加减操作)等致命缺陷,Sun 公司引入了 java.util.Calendar

在 Java 8 推出 java.time 包之前,Calendar 一直是 Java 中处理日期运算日历字段提取的核心主力。尽管现在它已经被标记为遗留 API,但在老项目中它依然无处不在。

核心概念与设计

核心概念与设计:

1. 它是抽象类

Calendar 本身是一个抽象类,无法直接 new。它提供了一个工厂方法 Calendar.getInstance()。在绝大多数情况下,这个方法会根据你操作系统的默认时区和语言环境,返回它的一个标准子类——GregorianCalendar(公历/格里高利历) 的实例。

2. 核心职责:统筹“日历字段”

如果说 Date 只是一个愚蠢的“毫秒时间戳包装盒”,那么 Calendar 就是一个真正的“日历”。它内部维护了一个数组,把时间戳拆解成了年份(YEAR)、月份(MONTH)、日期(DAY)、小时(HOUR)等各个日历字段(Fields),并允许你对这些字段进行独立的操作和数学运算。

设计缺陷

在使用 Calendar 时,有几个违背直觉的设定,是引发无数 Bug 的罪魁祸首:

  • 月份从 0 开始Calendar.JANUARY 的值是 0DECEMBER 的值是 11。如果你想设置 10 月,你必须传入 9,或者使用常量 Calendar.OCTOBER
  • 星期从星期日开始,且值为 1:在 Calendar 中,一周的第一天是星期日(值为 1),星期一是 2,以此类推,星期六是 7。
  • 它是可变的 (Mutable):和 Date 一样,调用 setadd 等方法会直接修改原对象,这意味着它在多线程环境下是绝对不安全的。

日历字段常量

在调用 API 之前,必须熟悉 Calendar 规定的核心字段常量,它们将作为参数高频出现:

  • Calendar.YEAR:年份
  • Calendar.MONTH:月份 (极其重要:0 表示 1 月,11 表示 12 月)
  • Calendar.DATECalendar.DAY_OF_MONTH:一个月中的第几天
  • Calendar.HOUR_OF_DAY:一天中的小时(24 小时制,0-23)
  • Calendar.HOUR:一天中的小时(12 小时制,0-11)
  • Calendar.MINUTE:分钟
  • Calendar.SECOND:秒
  • Calendar.MILLISECOND:毫秒
  • Calendar.DAY_OF_WEEK:星期几 (周日为 1,周一为 2,...,周六为 7)

API:Calendar

实例化对象

Calendar 是抽象类,不能直接 new,需要通过静态工厂方法获取其实例(通常返回的是 GregorianCalendar)。

  • static Calendar getInstance()(),使用默认时区和语言环境获得一个日历,时间初始化为当前系统时间。

  • static Calendar getInstance()(TimeZone zone),使用指定的时区获取当前时间的日历(常用于处理跨国时间)。

  • static Calendar getInstance()(Locale aLocale),使用指定的语言环境获取日历(影响星期的起始日等本地化习惯)。

java
// 1. 获取当前时间的日历对象
Calendar calendar = Calendar.getInstance();
Date/时间戳 转换

在老项目中,Calendar 往往负责计算,Datelong 负责数据传递或入库。

  • final Date getTime()(),将 Calendar 对象当前的日历时间转换为一个 java.util.Date 对象。

  • final void setTime()(Date date),使用给定的 Date 对象来设置Calendar 的当前时间。

  • long getTimeInMillis()(),返回此 Calendar时间戳(自 1970-01-01 00:00:00 GMT 以来的毫秒数)。

  • void setTimeInMillis()(long millis),使用给定的毫秒时间戳来设置Calendar 的当前时间。

java
// 1. 获取当前时间的日历对象
Calendar calendar = Calendar.getInstance();

// 2. Date -> Calendar
Date now = new Date();
calendar.setTime(now); // 把 Date 塞进 Calendar 中

// 3. Calendar -> Date
Date dateFromCal = calendar.getTime(); // 把 Calendar 算好的结果提取为 Date
提取设置日历字段

通过传入 Calendar 类中定义的常量,可以获取各个时间维度的值。

  • int get()(int field)获取指定日历字段的值。

  • void set()(int field, int value),将给定的日历字段设置为给定值。

    void set()(int year, int month, int date),便捷方法,同时设置年、月、日

    void set()(int year, int month, int date, int hourOfDay, int minute, int second),便捷方法,同时设置完整的年月日时分秒

  • final void clear()()清除所有日历字段的值(将其重置为 1970-01-01 00:00:00.000)。

    final void clear()(int field),只清除指定的日历字段。
    避坑场景:有时你只需要按天计算,不关心时分秒,需要用 clear() 清除时分秒和毫秒的影响,否则后续的日期比较(如 equals)会失败。

java
Calendar cal = Calendar.getInstance();

int year = cal.get(Calendar.YEAR);
// 必须 +1 才是符合人类直觉的真实月份
int month = cal.get(Calendar.MONTH) + 1;
int day = cal.get(Calendar.DAY_OF_MONTH); // 等同于 Calendar.DATE

int hour12 = cal.get(Calendar.HOUR);         // 12小时制 (0-11)
int hour24 = cal.get(Calendar.HOUR_OF_DAY);  // 24小时制 (0-23)
int minute = cal.get(Calendar.MINUTE);
int second = cal.get(Calendar.SECOND);

// 获取今天是星期几 (1=周日, 2=周一 ... 7=周六)
int dayOfWeek = cal.get(Calendar.DAY_OF_WEEK);

你可以单独修改某个字段,或者一次性设置年月日。

java
Calendar cal = Calendar.getInstance();

// 单独修改年份为 2025
cal.set(Calendar.YEAR, 2025);

// 极其注意:设置 10 月 25 日,月份必须传 9 (或者用常量)
cal.set(Calendar.MONTH, Calendar.OCTOBER);
cal.set(Calendar.DAY_OF_MONTH, 25);

// 连写格式 (年, 月, 日, 时, 分, 秒)
cal.set(2023, Calendar.OCTOBER, 25, 14, 30, 0);

// 清空所有字段 (重置为 1970-01-01 00:00:00)
cal.clear();
日期计算

这是 Calendar 最核心的功能,用于时间的加减。

  • void add()(int field, int amount)常用,根据日历的规则,将指定的时间量添加或减去指定的日历字段。
    特性会自动进位/借位。例如 1 月 31 日加 1 个月,会变成 2 月 28/29 日;12 月加 1 个月,年份会加 1。

    java
    // --- 演示 add() 的进位特性 ---
    Calendar cal1 = Calendar.getInstance();
    cal1.set(2023, Calendar.DECEMBER, 31); // 2023-12-31
    cal1.add(Calendar.MONTH, 1);           // 加 1 个月
    // 结果:2024-01-31 (年份自动进位了,变成了 2024 年!)
  • void roll()(int field, int amount)危险,向指定的日历字段添加或减去指定的时间量,但不更改更大的字段
    特性不进位,只在当前字段内循环

    java
    // --- 演示 roll() 的不进位特性 ---
    Calendar cal2 = Calendar.getInstance();
    cal2.set(2023, Calendar.DECEMBER, 31); // 2023-12-31
    cal2.roll(Calendar.MONTH, 1);          // 滚动 1 个月
    // 结果:2023-01-31 (2023 年 12 月 `roll` 加上 1 个月,会变成 2023 年 1 月,年份不变。)
边界查询

在业务中,经常需要知道“本月有多少天”、“今年有多少天”等边界问题。

  • int getActualMaximum()(int field)常用,返回指定日历字段可能拥有的最大值(考虑当前时间的上下文)。

    java
    // 经典用法(获取当月最后一天)
    Calendar cal = Calendar.getInstance();
    // 设置为某个特定的年月
    cal.set(Calendar.YEAR, 2024);
    cal.set(Calendar.MONTH, Calendar.FEBRUARY);
    // 自动计算闰年的 2 月有多少天 (返回 29)
    int lastDay = cal.getActualMaximum(Calendar.DAY_OF_MONTH);
  • int getActualMinimum()(int field),返回指定日历字段可能拥有的最小值。对于大多数字段(如天数),最小值通常固定为 1。

时间比较

虽然 Calendar 可以转成时间戳通过 >=<= 比较,但它自身也提供了便捷的方法:

  • boolean before()(Object when),判断此 Calendar 表示的时间是否在指定的 Object(必须也是一个 Calendar)表示的时间之前

  • boolean after()(Object when),判断此 Calendar 表示的时间是否在指定的 Object 表示的时间之后

  • int compareTo()(Calendar anotherCalendar),比较两个日历对象的时间先后顺序
    返回 0 表示相等;
    返回负数表示当前日历更早;
    返回正数表示当前日历更晚。

java
Calendar cal = Calendar.getInstance();

// 比较先后顺序
Calendar future = Calendar.getInstance();
future.add(Calendar.DAY_OF_MONTH, 5);
boolean isAfter = future.after(cal); // true

现代平替:迁移到 Java 8+

现代平替:迁移到 Java 8+:

由于 Calendar 存在“可变对象(线程不安全)”和“魔数常量(0 代表 1 月)”的糟糕设计,如果你在维护老项目时拿到了一个 Calendar 对象,建议立刻将其转换为 Java 8 的新 API 以进行后续的业务逻辑处理。

桥接方法:转为 ZonedDateTime

Calendar 相比 Date 的进步在于它包含了时区信息。因此,它最好的转换目标是现代的带时区时间类 ZonedDateTime

java
import java.util.Calendar;
import java.time.ZonedDateTime;
import java.time.LocalDateTime;

public class CalendarMigration {
    public static void main(String[] args) {
        Calendar legacyCal = Calendar.getInstance();

        // 1. Calendar 转 ZonedDateTime (Java 8 官方推荐的桥接方式)
        ZonedDateTime zdt = legacyCal.toInstant().atZone(legacyCal.getTimeZone().toZoneId());

        // 2. 如果你不需要时区,可以进一步转为 LocalDateTime
        LocalDateTime ldt = zdt.toLocalDateTime();

        System.out.println("现代 API 时间: " + ldt);
    }
}

练习题

练习:输入年份和月份,输出该月日历

闰年计算公式:年份可以被4整除但不能被100整除,或者可以被400整除。

image-20220503120722810

JDK8 及之后

旧 API 的设计缺陷

如果我们可以跟别人说:“我们在1502643933071见面,别晚了!”那么就再简单不过了。但是我们希望时间与昼夜和四季有关,于是事情就变复杂了。JDK 1.0中包含了一个java.util.Date类,但是它的大多数方法已经在JDK 1.1引入Calendar类之后被弃用了。而Calendar并不比Date好多少。它们面临的问题是:

  • 可变性:像日期和时间这样的类应该是不可变的。

  • 偏移性:Date中的年份是从1900开始的,而月份都从0开始。

  • 格式化:格式化只对Date有用,Calendar则不行。

  • 此外,它们也不是线程安全的;不能处理闰秒等。

    闰秒,是指为保持协调世界时接近于世界时时刻,由国际计量局统一规定在年底或年中(也可能在季末)对协调世界时增加或减少1秒的调整。由于地球自转的不均匀性和长期变慢性(主要由潮汐摩擦引起的),会使世界时(民用时)和原子时之间相差超过到±0.9秒时,就把协调世界时向前拨1秒(负闰秒,最后一分钟为59秒)或向后拨1秒(正闰秒,最后一分钟为61秒); 闰秒一般加在公历年末或公历六月末。

    目前,全球已经进行了27次闰秒,均为正闰秒。

总结:对日期和时间的操作一直是Java程序员最痛苦的地方之一。

java.time

经过了前面关于 java.util.DateCalendar 等旧版 API 的“痛苦折磨”后,我们终于迎来了 Java 开发者的福音 Java 8 引入的 java.time

java.time 包是由 Joda-Time 的作者 Stephen Colebourne 领导设计的全新 API(JSR 310)。它彻底抛弃了历史包袱,基于领域驱动设计 (DDD,Domain-Driven Design),拥有绝对的线程安全性(不可变性)和极其优雅的流式调用(Fluent API)

核心设计理念

  1. 不可变性 (Immutability):所有的日期时间类(如 LocalDate)一旦创建就不能被修改。任何诸如“加一天”、“改年份”的操作,都会返回一个全新的对象。这彻底消灭了多线程并发修改导致的 Bug。

  2. 职责单一 (Separation of Concerns):旧版 Date 揉合了日期、时间、时区。新版将其严格拆分为:机器时间、不带时区的人类时间、带时区的人类时间。

  3. 符合人类直觉:月份终于从 1 开始算起了(1 月就是 1),星期一到星期日对应的枚举值是 17

time 包组成

java.time 并不是孤立的一个包,它包含五个核心包,各自承担不同的架构职责:

  1. java.time(核心基石包)

    这是开发者日常 90% 工作都在打交道的包。它包含了所有代表时间点时间段的值类型对象。

    • 人类视角(无时区)LocalDate, LocalTime, LocalDateTime
    • 机器视角(绝对时间)Instant
    • 全球视角(带时区/偏移)ZonedDateTime, OffsetDateTime, OffsetTime
    • 时间碎片(残缺时间)Year, YearMonth, MonthDay(非常适合处理诸如信用卡有效期、每年固定生日等业务场景)。
    • 时间跨度
      • Duration:物理时间跨度(基于时分秒纳秒,如“耗时 5 分钟”)。
      • Period:日历时间跨度(基于年月日,如“相差 2 年 3 个月”)。
  2. java.time.format(格式化与解析包)

    这个包彻底埋葬了那个臭名昭著的、线程不安全的 SimpleDateFormat

    • 核心类:DateTimeFormatter
      • 核心优势:绝对线程安全!你可以(且应该)将它声明为 public static final 全局共享。
      • 它内置了大量标准格式(如 DateTimeFormatter.ISO_LOCAL_DATE),也支持通过 ofPattern("yyyy-MM-dd") 自定义。
    • 高级类:DateTimeFormatterBuilder
      • 用于构建极其复杂的自定义解析逻辑(例如解析带有各种奇葩后缀、可选字段的时间字符串)。
  3. java.time.temporal(底层时间算法与调节器包)

    这是高级开发者的“魔法武器库”。它提供了底层的加减算法和极度灵活的日历推演。

    • ChronoUnit(时间单位枚举):定义了从纳秒(NANOS)到纪元(ERAS)的各种加减单位。
    • ChronoField(时间字段枚举):定义了极其精细的时间维度(例如 DAY_OF_YEAR, ALIGNED_WEEK_OF_MONTH)。
    • TemporalAdjusters(时间调节器)神仙工具类。专治各种变态的业务日历逻辑:
      • “本月的最后一个周五” -> TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY)
      • “下一个工作日” -> 可以自定义一个 TemporalAdjuster 来排除周末和法定节假日。
  4. java.time.zone(时区规则解析包)

    底层的时区数据库支持。普通的 CRUD 程序员很少直接碰它,但如果你在做跨国金融系统,必须了解它。

    • ZoneRules:维护了全球各个地区从古至今的时区演变规则(例如历史上某个国家哪一年废除了夏令时)。
    • 夏令时(DST)重叠与跳跃处理:当夏令时开始或结束时,时间会凭空消失一小时或重复一小时,这个包提供了处理这些时间断层的底层规则。
  5. java.time.chrono(非公历系统包)

    Java 不仅支持公历(ISO-8601),还内置了对其他历法的支持。

    • HijrahDate(伊斯兰教历)
    • JapaneseDate(日本和历,支持年号如“令和”、“平成”)
    • MinguoDate(中国台湾的民国纪年)
    • ThaiBuddhistDate(泰国佛教历)

API:日期时间类

FIELD@

方法名适用类返回值与说明
XxxYear()Date / DateTime返回/设置/加减:年份 (如 2023)
XxxMonthValue()Date / DateTime返回/设置/加减:真实的月份数字 (1 - 12)
XxxDayOfMonth()Date / DateTime返回/设置/加减:当月的第几天 (1 - 31)
XxxHour()Time / DateTime返回/设置/加减:小时 (0 - 23)
XxxMinute()Time / DateTime返回/设置/加减:分钟 (0 - 59)
XxxSecond()Time / DateTime返回/设置/加减:秒数 (0 - 59)
XxxNano()Time / DateTime返回/设置/加减:纳秒数 (0 - 999,999,999)
XxxMonth()Date / DateTime返回/设置/加减: Month 枚举 (如 OCTOBER)
XxxDayOfWeek()Date / DateTime返回/设置/加减: DayOfWeek 枚举
通过 .getValue() 可获取 1-7 的数字

通用方法@

不论你是操作 LocalDate(日期)、LocalTime(时间)、LocalDateTime(日期时间),还是带有绝对时区的 ZonedDateTime,或者是机器时间 Instant,它们的 API 骨架几乎是完全一样的。

对象创建与解析

由于它们是不可变类且没有 public 构造函数,创建对象必须依赖静态工厂方法。

  • static <日期时间类> now()(),获取系统默认时区下的当前日期/时间/日期时间/瞬时点

    java
    static LocalDate now() // 当前日期
    static LocalTime now() // 当前时间
    static LocalDateTime now() // 当前日期时间
    static ZonedDateTime now() // 当前日期时间(带时区)
    static Instant now() // 当前瞬时点
    java
    // 示例
    LocalDate today = LocalDate.now();
    LocalTime nowTime = LocalTime.now();
    LocalDateTime nowDt = LocalDateTime.now();
  • static <日期时间类> of()(...),通过指定具体的年月日/时分秒/时区创建日期/时间类的实例。

    java
    static LocalDate of(时,分,秒?,纳秒?) // 年月日
    static LocalTime of(时,分,秒?,纳秒?) // 时分秒
    static LocalDateTime of(年,月,日,时,分,秒?,纳秒?) // 年月日时分秒纳秒
    static ZonedDateTime of(年,月,日,时,分,秒?,纳秒?, ZoneId zone) // 年月日时分秒时区
    // 没有 Instant
    java
    // 示例
    LocalDate myDate = LocalDate.of(2023, 10, 25);
    LocalTime myTime = LocalTime.of(14, 30, 0); // 14点30分0秒
    LocalDateTime myDt = LocalDateTime.of(2023, 10, 25, 14, 30, 0);
  • static <日期时间类> from()(TemporalAccessor temporal),从给定的时态对象 temporal 获取指定日期/时间/Instant类的实例。

    java
    static LocalDate from(TemporalAccessor temporal) // 日期
    static LocalTime from(TemporalAccessor temporal) // 时间
    static LocalDateTime from(TemporalAccessor temporal) // 日期时间
    static ZonedDateTime from(TemporalAccessor temporal) // 日期时间(带时区)
    static Instant from(TemporalAccessor temporal) // 瞬时点
    java
    // 示例【
  • static <日期时间类> parse()(CharSequence text),将符合 ISO-8601 标准的字符串解析日期/时间/Instant类的实例

    java
    static LocalDate parse(CharSequence text, DateTimeFormatter formatter?) // (使用特定格式)解析为 日期
    static LocalTime parse(CharSequence text) // 解析为 时间
    static LocalDateTime parse(CharSequence text) // 解析为 日期时间
    static ZonedDateTime parse(CharSequence text) // 解析为 日期时间(带时区)
    static Instant parse(CharSequence text) // 解析为 瞬时点
    java
    LocalDate parsedDate = LocalDate.parse("2023-10-25");
    LocalTime parsedTime = LocalTime.parse("14:30:00");
    LocalDateTime parsedDt = LocalDateTime.parse("2023-10-25T14:30:00"); // 解析完整字符串,注意中间的 'T' 是国际标准规定的分隔符
提取/修改/加减时间
  • int get()(TemporalField field)通用的底层获取方式,配合 ChronoField 枚举使用。

  • int getFIELD()(),返回年月日时分秒等字段。

    java
    LocalDateTime now = LocalDateTime.now(); // 假设现在是 2026-05-14 14:30:00
    
    int year = now.getYear(); // 2026
    int month = now.getMonthValue(); // 5
    int day = now.getDayOfMonth(); // 14
  • withFIELD()(int FIELD),返回设置指定的年月日时分秒等字段后的日期时间副本。

    java
    LocalDateTime time = LocalDateTime.now();
    
    // 把年份强制修改为 2025,把小时修改为 8 点 (其他字段不变)
    LocalDateTime modifiedTime = time.withYear(2025).withHour(8);
  • plusFIELDS()(long FIELDS)

    minusFIELDS()(long FIELDS),返回增加/减少指定的年月日时分秒等字段后的日期时间副本。
    注意:它们会自动处理复杂的进位/借位以及闰年逻辑(智能进位)。

    java
    LocalDateTime dt = LocalDateTime.of(2023, 1, 31, 10, 0);
    
    // --- 增加时间 (plus) ---
    LocalDateTime tomorrow = dt.plusDays(1);    // 加 1 天
    LocalDateTime nextMonth = dt.plusMonths(1); // 加 1 个月 (智能进位:1月31日变成2月28日)
    LocalDateTime nextYear = dt.plusYears(1);   // 加 1 年
    LocalDateTime later = dt.plusHours(2).plusMinutes(30); // 链式调用:加 2小时30分
    
    // --- 减少时间 (minus) ---
    LocalDateTime yesterday = dt.minusDays(1);    // 减 1 天
    LocalDateTime lastWeek = dt.minusWeeks(1);    // 减 1 周
    LocalDateTime earlier = dt.minusSeconds(60);  // 减 60 秒
对象拼装与拆解

这三大类经常需要相互转换,API 设计得像乐高积木一样:

1. 拼装 (at 系列):将小的粒度拼成大的粒度

java
LocalDate date = LocalDate.of(2023, 10, 25);
LocalTime time = LocalTime.of(14, 30);

// LocalDate 补充时间 -> LocalDateTime
LocalDateTime dt1 = date.atTime(time);        // 2023-10-25T14:30
LocalDateTime dt2 = date.atTime(0, 0, 0);     // 补充特定时间:午夜零点
LocalDateTime dt3 = date.atStartOfDay();      // 等同于上一句,更优雅的快捷方法

// LocalTime 补充日期 -> LocalDateTime
LocalDateTime dt4 = time.atDate(date);

2. 拆解 (to 系列):将大的粒度拆分为小的粒度

java
LocalDateTime dt = LocalDateTime.now();

// LocalDateTime 降维提取
LocalDate justDate = dt.toLocalDate();
LocalTime justTime = dt.toLocalTime();
时间比较

极其直观的比较方法。

  • boolean isBefore()(ChronoLocalDateTime other),判断当前对象是否在目标时间之

  • boolean isAfter()(ChronoLocalDateTime other),判断当前对象是否在目标时间之

  • boolean isEqual()(ChronoLocalDateTime other),判断当前对象是否等于指定日期时间。

java
LocalDate date1 = LocalDate.of(2023, 10, 25);
LocalDate date2 = LocalDate.of(2023, 11, 1);

boolean b1 = date1.isBefore(date2); // true  (date1 在 date2 之前吗?)
boolean b2 = date1.isAfter(date2);  // false (date1 在 date2 之后吗?)
boolean b3 = date1.isEqual(date2);  // false (date1 和 date2 相等吗?)

// 实用技巧:检查一个日期是否在某两个日期之间
boolean isBetween = date1.isAfter(startDate) && date1.isBefore(endDate);

这三大类的 API 虽然繁多,但因为其链式调用和极具语义化的命名,写起来非常丝滑。

在实际业务中,由于我们常常要处理每个月的最后一天、下个星期的某一天等复杂的日历推算,Java 8 特别提供了一个专门做复杂计算的工具类 TemporalAdjusters。你需要我为你单独展开讲讲这个被称为“时间魔法棒”的高级工具吗?

LocalDate

LocalDate:专注于日历计算,不含时分秒。因此,所有关于“天数”、“闰年”的特有逻辑都在这里。

  • boolean isLeapYear()(),判断是否是闰年
    再也不用手写 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) 这种恶心的代码了。

  • int lengthOfMonth()()

    int lengthOfYear()(),获取当前月份/年份的总天数(自动处理闰年 2 月 29 天)。

  • Stream<LocalDate> datesUntil()(LocalDate endExclusive)JDK9。极其适合做日历打卡、统计图表横坐标的生成!

java
LocalDate today = LocalDate.now();

// 1. 极其优雅的闰年及天数判断
boolean isLeap = today.isLeapYear();
int daysThisMonth = today.lengthOfMonth(); // 比如返回 30, 31, 28, 29

// 2. 生成从今天起,未来 7 天的日期流 (极其适合按天生成报表横坐标)
List<LocalDate> next7Days = today.datesUntil(today.plusDays(7))
                              .collect(Collectors.toList());
System.out.println("未来7天: " + next7Days);

LocalTime

LocalTime:专注于一天之内的时分秒,没有跨天的概念。

  • int toSecondOfDay()()

    long toNanoOfDay()(),计算当前时间距离今天午夜 00:00:00 经过了多少秒/纳秒

  • LocalTime truncatedTo()(TemporalUnit unit)截断时间精度
    在业务中,如果前端只需要精确到分钟,或者存入数据库时想抹去毫秒,这个方法是神器。(注:LocalDateTime 等也有此方法,但在这里最常用)

极值常量

  • LocalTime.MIN00:00
  • LocalTime.MAX23:59:59.999999999
  • LocalTime.MIDNIGHT
  • LocalTime.NOON
java
LocalTime time = LocalTime.now(); // 14:35:12.876

// 1. 抹去零头:截断到分钟 (秒和纳秒全部归零)
LocalTime truncatedTime = time.truncatedTo(ChronoUnit.MINUTES);
System.out.println("截断后: " + truncatedTime); // 14:35:00

// 2. 获取当天已经过去的秒数 (例如做每日任务重置计算)
int passedSeconds = time.toSecondOfDay();

// 3. 构建今天的最后期限 (常用于拼装 SQL 的 end_time)
LocalDateTime endOfToday = LocalDate.now().atTime(LocalTime.MAX);

LocalDateTime

LocalDateTime:日历与时间的完美结合体。它没有太多独特的算术方法,它最大的特长是“作为桥梁连接一切”。

它主要负责和时区打交道,将自己“升维”。

  • ZonedDateTime atZone()(ZoneId zone),将本地时间赋予时区灵魂,升级为 ZonedDateTime

  • OffsetDateTime atOffset()(ZoneOffset offset),赋予时间偏移量(如 +08:00),升级为 OffsetDateTime

java
LocalDateTime localDt = LocalDateTime.now(); // 机器只知道 14:30,不知道在哪

// 赋予时区灵魂:告诉系统这是北京的 14:30
ZonedDateTime beijingTime = localDt.atZone(ZoneId.of("Asia/Shanghai"));

ZonedDateTime

ZonedDateTime:绝对时间点 + 地理位置(时区规则)。它是处理跨国业务的绝对主力。

  • ZonedDateTime withZoneSameInstant()(ZoneId zone)(极其常用,核心桥梁) 改变时区,但保持绝对时间瞬间不变
  • ZonedDateTime withZoneSameLocal()(ZoneId zone)(极少使用,非常危险) 改变时区,但强行保持钟表上的时间数字不变
java
// 假设当前是北京时间 2023-10-25 20:00
ZonedDateTime beijingTime = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));

// 正确的时区转换姿势:北京晚上8点,纽约是几点?(绝对时间不变)
ZonedDateTime nyTime = beijingTime.withZoneSameInstant(ZoneId.of("America/New_York"));
// 打印结果:2023-10-25T08:00[America/New_York] (纽约早上8点)

// 提取当前时区和偏移量
ZoneId zone = beijingTime.getZone();       // Asia/Shanghai
ZoneOffset offset = beijingTime.getOffset(); // +08:00

Instant

Instant 在 Java 8 及之后的日期时间 API 中,扮演着取代 java.util.Date 的核心角色。它代表的是时间轴上的一个绝对瞬时点(基于 UTC 零时区),精度高达纳秒,非常适合用于记录系统时间戳、日志输出或跨国业务的绝对时间线。

对象创建

这一组 API 用于从当前系统时间、数字时间戳或字符串中获取 Instant 实例。

  • static Instant ofEpochMilli()(long epochMilli),通过指定的毫秒级时间戳创建对象。

  • static Instant ofEpochSecond()(long epochSecond),通过指定的秒级时间戳创建对象。

    java
    // 1. 将普通的毫秒时间戳转换为 Instant
    long currentMillis = System.currentTimeMillis();
    Instant fromMilli = Instant.ofEpochMilli(currentMillis);
    
    // 2. 将秒级时间戳转换为 Instant (常用于与外部系统对接)
    Instant fromSecond = Instant.ofEpochSecond(1672531200L);
提取时间戳

当我们需要将 Instant 转回基础的 long 类型数据存入数据库或进行传统的数学计算时,使用这一组 API。

  • long toEpochMilli()(),将当前瞬间对象转换并提取为毫秒级时间戳(等同于 Date.getTime())。

  • long getEpochSecond()(),将当前瞬间对象转换并提取为秒级时间戳(去除毫秒及以下精度)。

    java
    Instant instant = Instant.now();
    
    // 1. 提取毫秒时间戳 (日常开发最常用)
    long millis = instant.toEpochMilli();
    System.out.println("毫秒时间戳: " + millis);
    
    // 2. 提取秒级时间戳
    long seconds = instant.getEpochSecond();
    System.out.println("秒级时间戳: " + seconds);

API:ZoneId

ZoneId 是一个抽象类,它代表了一个时区的身份标识。它内部包含了时区规则(ZoneRules,这些规则决定了在历史上的某一个特定瞬间,该地区的时钟到底指在几点几分。

它主要有两个具体的实现

  1. 基于地理区域的 ID(极度推荐):例如 "Asia/Shanghai""Europe/Paris"。它包含了夏令时等复杂的历史规则。

  2. 固定偏移量(ZoneOffset):例如 "+08:00"。它是 ZoneId 的子类,代表一个死板的、永远不变的时间差。

注意事项

错把 +08:00 等同于 Asia/Shanghai

  • 很多开发者图省事,直接用固定的 +08:00 来代替北京/上海时间。
  • 灾难后果:中国在 1986 年到 1991 年间实行过夏令时!如果你用固定的 +08:00 去解析 1988 年夏天的某个历史订单时间,算出来的时间是完全错误的。Asia/Shanghai 知道这段夏令时历史,而 +08:00 不知道。

盲目信任 ZoneId.systemDefault()

  • 容器化(Docker/K8s)时代,如果你没在 Dockerfile 里配置 TZ 环境变量,你的 Java 程序默认拿到的是 UTC 时间!导致写入数据库的时间比北京时间少 8 个小时。

获取创建实例

  • static ZoneId systemDefault()(),获取运行当前 JVM 的操作系统的默认时区
  • static ZoneId of()(String zoneId),通过标准的时区字符串 ID(如 "Asia/Tokyo")获取或创建一个时区实例。
  • static ZoneId ofOffset()(String prefix, ZoneOffset offset),根据前缀(如 "UTC" 或 "GMT")和偏移量创建一个 ZoneId(极少用,通常直接用 ZoneOffset.of)。
java
// 1. 获取系统默认时区 (🚨 容器化部署时一定要确认容器的时区配置)
ZoneId defaultZone = ZoneId.systemDefault();
System.out.println("系统默认时区: " + defaultZone); // 输出如: Asia/Shanghai

// 2. 根据标准地理 ID 获取时区 (⭐ 全球化业务最常用)
ZoneId tokyoZone = ZoneId.of("Asia/Tokyo");
ZoneId nyZone = ZoneId.of("America/New_York");

// 3. 根据固定偏移量获取 (注意:它返回的其实是子类 ZoneOffset)
ZoneId offsetZone = ZoneId.of("+08:00");

获取可用时区集合

探索可用的时区集合 (getAvailableZoneIds):

如果你要做一个下拉菜单,让用户选择自己所在的时区,这个 API 是为你准备的。

  • static Set<String> getAvailableZoneIds()(),获取 Java 底层内置支持的所有合法时区 ID 字符串集合(通常有 600 多个)。
java
// 获取全球所有支持的时区 ID
Set<String> allZones = ZoneId.getAvailableZoneIds();

// 打印总数
System.out.println("支持的时区总数: " + allZones.size());

// 查找所有包含 "Asia" 的时区
allZones.stream()
      .filter(z -> z.startsWith("Asia/"))
      .forEach(System.out::println);
// 输出示例: Asia/Shanghai, Asia/Tokyo, Asia/Seoul...

获取时区底层规则

这是高级架构师排查夏令时 Bug 时的利器。你可以探明某一个时区在某一个具体的时刻,究竟有没有实行夏令时。

  • ZoneRules getRules()(),获取该时区 ID 背后极其复杂的历史时区和夏令时演变规则
java
// 探究美国纽约的时区规则
ZoneId nyZone = ZoneId.of("America/New_York");
ZoneRules rules = nyZone.getRules();

// 构建一个纽约的冬天时间 (非夏令时) 和夏天时间 (夏令时)
LocalDateTime winterTime = LocalDateTime.of(2023, 1, 15, 12, 0);
LocalDateTime summerTime = LocalDateTime.of(2023, 7, 15, 12, 0);

// 🚨 魔法发生:同一个城市,不同季节,与 UTC 的时间差居然不一样!
System.out.println("冬季偏移量: " + rules.getOffset(winterTime)); // -05:00
System.out.println("夏季偏移量: " + rules.getOffset(summerTime)); // -04:00 (夏令时拨快了1小时)

// 直接询问:这个瞬间是否处于夏令时?
boolean isDst = rules.isDaylightSavings(summerTime.atZone(nyZone).toInstant());
System.out.println("7月15日是否是夏令时: " + isDst); // true

规范化 ID

  • ZoneId normalized()(),将当前时区对象规范化。如果当前的 ZoneId 实际上是一个固定的偏移量(且不是地理区域),它会将其转换为标准的 ZoneOffset 实例。
java
ZoneId z1 = ZoneId.of("UTC+08:00");
System.out.println(z1.getClass().getSimpleName()); // ZoneRegion

ZoneId z2 = z1.normalized();
System.out.println(z2.getClass().getSimpleName()); // ZoneOffset (被优化和规范化了)

API:TemporalAdjusters

TemporalAdjusters 里面全是一堆静态工厂方法,它们返回的都是 TemporalAdjuster(时间调节器) 接口的实例。

它的底层设计是经典的策略模式(Strategy Pattern)

LocalDate 等时间对象只负责存储数据,而将“怎么把时间调整到目标状态”的算法逻辑,外包给了 TemporalAdjuster

用法:用法极其统一:时间对象.with(调节器)

注意事项

陷阱:错用在没有日期的对象上(引发运行时异常)

  • 绝大部分调节器(如 lastDayOfMonth)都是基于“日期”维度的。如果你傻乎乎地把它用在 LocalTime(只包含时分秒)上,会直接抛出 UnsupportedTemporalTypeException

陷阱:分不清 next()nextOrSame() 的致命差别

  • 假设今天是星期五
  • 你调用 next(DayOfWeek.FRIDAY):它会跳过今天,严格返回下周的星期五
  • 你调用 nextOrSame(DayOfWeek.FRIDAY):它发现今天刚好就是星期五,于是直接返回今天
  • 在做自动扣款或定时任务计算时,选错这两个方法会导致业务差整整一周!

月初/年末

专门用于财务报表、账单周期这种强依赖自然月/自然年的场景。

  • static TemporalAdjuster firstDayOfMonth()(),当月第一天。
  • static TemporalAdjuster lastDayOfMonth()(),当月最后一天(极其智能,自动处理 28/29/30/31 天)。
  • static TemporalAdjuster firstDayOfYear()(),当年的第一天。
  • static TemporalAdjuster lastDayOfYear()(),当年最后一天。
  • static TemporalAdjuster firstDayOfNextMonth()(),下个月的第一天。
java
LocalDate today = LocalDate.of(2024, 2, 15); // 2024 是闰年

// 找月末 (不用再手写判断闰年和月份天数的逻辑了!)
LocalDate lastDay = today.with(TemporalAdjusters.lastDayOfMonth());
System.out.println("2月最后一天: " + lastDay); // 2024-02-29

// 下个月的第一天
LocalDate nextMonthFirstDay = today.with(TemporalAdjusters.firstDayOfNextMonth());

相对星期

常用于计算“下个工作日”、“上个发版日”。

  • static TemporalAdjuster next()(DayOfWeek),严格寻找下一个指定的星期几(不含今天)。
  • static TemporalAdjuster nextOrSame()(DayOfWeek),寻找下一个指定的星期几(含今天。如果今天刚好是,就返回今天)。
  • static TemporalAdjuster previous()(DayOfWeek),严格寻找上一个指定的星期几。
  • static TemporalAdjuster previousOrSame()(DayOfWeek),寻找上一个指定的星期几(含今天)。
java
// 假设今天是 2023-10-25 (星期三)
LocalDate today = LocalDate.of(2023, 10, 25);

// 找下一个星期五
LocalDate nextFriday = today.with(TemporalAdjusters.next(DayOfWeek.FRIDAY));
System.out.println("下个周五: " + nextFriday); // 2023-10-27

月内星期

专治各种西方节假日(感恩节、母亲节)或特殊的公司发薪日(如“每个月最后一个周五发工资”)。

  • static TemporalAdjuster firstInMonth()(DayOfWeek),当月第一个星期几。
  • static TemporalAdjuster lastInMonth()(DayOfWeek),当月最后一个星期几。
  • static TemporalAdjuster dayOfWeekInMonth()(int ordinal, DayOfWeek),当月第 N 个星期几。(ordinal 可以是负数,代表倒数,非常强大!)
java
LocalDate today = LocalDate.of(2023, 5, 1);

// 1. 计算母亲节:5月的第 2 个星期日
LocalDate mothersDay = today.with(TemporalAdjusters.dayOfWeekInMonth(2, DayOfWeek.SUNDAY));
System.out.println("母亲节: " + mothersDay);

// 2. 公司发薪日:每个月最后一个工作日 (周五)
LocalDate payDay = today.with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY));
System.out.println("发薪日: " + payDay);

// 3. 神奇的负数:本月倒数第 2 个星期一
LocalDate magicalDay = today.with(TemporalAdjusters.dayOfWeekInMonth(-2, DayOfWeek.MONDAY));

自定义时间调节器

TemporalAdjusters 提供的虽然多,但总有些变态业务满足不了,比如“计算下一个工作日(跳过周六周日)”。

这时候,你可以利用 Lambda 表达式自己写一个调节器!

java
// 计算下一个工作日
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalAdjuster;

public class CustomAdjusterExample {

    // 静态定义一个自定义的调节器
    public static final TemporalAdjuster NEXT_WORKING_DAY = temporal -> {
        // 先把时间往后推一天
        temporal = temporal.plus(1, ChronoUnit.DAYS);
        // 获取星期几 (1=周一, ..., 7=周日)
        int dayOfWeek = temporal.get(ChronoField.DAY_OF_WEEK);

        // 如果变成了周六,再加2天跳过周末
        if (dayOfWeek == 6) {
            return temporal.plus(2, ChronoUnit.DAYS);
        }
        // 如果变成了周日,再加1天跳过周日
        else if (dayOfWeek == 7) {
            return temporal.plus(1, ChronoUnit.DAYS);
        }
        // 工作日直接返回
        return temporal;
    };

    public static void main(String[] args) {
        LocalDate friday = LocalDate.of(2023, 10, 27); // 星期五

        // 见证奇迹的时刻:星期五的下一个工作日,自动跳过周末变成了星期一
        LocalDate nextWorkingDay = friday.with(NEXT_WORKING_DAY);
        System.out.println("下一个工作日: " + nextWorkingDay); // 2023-10-30 (周一)
    }
}

API:Period

Period:专门用于表示基于“日历(年月日)”的时间跨度。例如:“2 年 3 个月零 4 天”。

它的内部极其简单,只维护了三个 int 类型的变量:years(年)、months(月)、days(天)。

核心前提:因为它是基于日历的,所以它只能用于处理 LocalDate(或者包含日期的 LocalDateTime,绝对不能用于只包含时分秒的 LocalTime

创建与获取

这是获取 Period 实例最常用的方法。

  • static Period between()(LocalDate startDateInclusive, LocalDate endDateExclusive)最常用,计算两个日期之间的日历差。遵循“包头不包尾”(左闭右开)的原则。

  • static Period of()(int years, int months, int days)直接指定年月日跨度。也有 ofYears, ofMonths, ofDays 等单维度的快捷方法。

    java
    static Period ofYears(int years) // 指定年跨度
    static Period ofMonths(int months) // 指定年跨度
    static Period ofDays(int days) // 指定年跨度
  • static Period parse()(CharSequence text)解析 ISO-8601 标准的周期格式(以 P 开头,如 P2Y3M4D 代表 2 年 3 个月 4 天)。

java
LocalDate birthDate = LocalDate.of(1995, 5, 20);
LocalDate today = LocalDate.now(); // 假设今天是 2023-10-25

// 1. 最经典用法:计算年龄
Period age = Period.between(birthDate, today);
System.out.printf("你的年龄是:%d 岁 %d 个月 %d 天\n",
              age.getYears(), age.getMonths(), age.getDays());

// 2. 直接构造一个时间跨度:例如“保质期 1年零6个月”
Period shelfLife = Period.of(1, 6, 0);
// 或者单维度构造
Period trialPeriod = Period.ofDays(15); // 15天试用期

// 3. 从标准字符串解析 (通常用于配置文件)
Period parsedPeriod = Period.parse("P1Y2M3D"); // 1年2个月3天

维度提取

用于把时间跨度对象拆解,单独获取某一个维度的值。

  • int getYears()(),获取年数部分。

  • int getMonths()(),获取月数部分。

  • int getDays()(),获取天数部分(切记:这不是总天数!)。

java
Period p = Period.of(2, 5, 10);
System.out.println("年: " + p.getYears());   // 2
System.out.println("月: " + p.getMonths());  // 5
System.out.println("天: " + p.getDays());    // 10

加减乘计算

Period 作为一个“时间跨度对象”,可以直接参与数学运算。

  • Period plus()(TemporalAmount amountToAdd)加上另一个跨度。

  • Period minus()(TemporalAmount amountToSubtract)减去另一个跨度。

  • Period multipliedBy()(int scalar),将当前跨度的年月日同时乘以一个倍数。

java
Period p1 = Period.ofYears(1).plusMonths(2); // 1年2个月
Period p2 = Period.ofMonths(3);              // 3个月

// 跨度相加
Period sum = p1.plus(p2); 
System.out.println("相加后: " + sum.getYears() + "年" + sum.getMonths() + "个月"); // 1年5个月

// 跨度相乘 (例如:分期付款3期,每期2个月,总跨度是多少?)
Period totalInstallment = Period.ofMonths(2).multipliedBy(3);
System.out.println("总跨度: " + totalInstallment.getMonths() + "个月"); // 6个月

标准化

这是一个非常有意思的 API。

假设你手动创建了一个 Period.of(1, 15, 0)(1 年 15 个月)。这个表述在人类看来很别扭,正常人会说“2 年 3 个月”。

normalized() 方法就是用来做这件事的:它会将超过 12 的月份自动进位到年份

避坑注意:normalized() 绝对不会去进位“天数”!

因为程序不知道这个月是 28 天、30 天还是 31 天。所以 Period.of(0, 1, 40) 是无法把 40 天进位成 1 个月零几天的。

java
// 手动造一个极其别扭的跨度:1年 15个月
Period weirdPeriod = Period.of(1, 15, 0);

// 标准化进位
Period normalPeriod = weirdPeriod.normalized();
System.out.printf("标准化后: %d 年 %d 个月\n", normalPeriod.getYears(), normalPeriod.getMonths());
// 输出结果: 标准化后: 2 年 3 个月

状态判定

  • boolean isZero()(),判断这个时间跨度是否为零(年月日全为 0)。

  • boolean isNegative()(),判断这个时间跨度中,是否有任何一个单位是负数。注意,只要年月日中有一个是负数,它就会返回 true

java
Period p1 = Period.of(0, 0, 0);
System.out.println("是否为零: " + p1.isZero()); // true

// 比如计算倒推日期时的跨度
Period p2 = Period.of(1, -2, 0); // 1年零负2个月
System.out.println("是否为负: " + p2.isNegative()); // true

API:Duration

Duration:专门用于表示基于“物理时间(时、分、秒、纳秒)”的时间跨度。例如:“耗时 2 小时 30 分钟”、“相差 500 毫秒”。

它的内部只存了两个数字:seconds(总秒数,long 类型)和 nanos(纳秒零头,int 类型)。

掐表与构造

  • static Duration between()(Temporal startInclusive, Temporal endExclusive)最常用,计算两个时间点(如 LocalTime, LocalDateTime, Instant)的绝对耗时差
  • static Duration ofXxx()(long amount)直接构造时长。
    包括 ofDays, ofHours, ofMinutes, ofSeconds, ofMillis, ofNanos
  • static Duration parse()(CharSequence text)解析 ISO-8601 标准字符串。
    PT 开头,如 PT2H30M 代表 2 小时 30 分。
java
Instant start = Instant.now();
// ... 执行一段耗时的业务逻辑 ...
Instant end = Instant.now();

// 1. 计算代码执行耗时 (极其常用)
Duration elapsed = Duration.between(start, end);

// 2. 构造一个缓存过期时间:2 小时 30 分钟
Duration cacheTtl = Duration.ofHours(2).plusMinutes(30);

// 3. 从配置文件解析:PT15M 代表 15 分钟
Duration timeout = Duration.parse("PT15M");

数据提取

这是 Duration 最核心的读取 API,一定要分清 to (转换总计)to...Part (提取零头,Java 9+) 的区别。

  • toXxx()(),将整个持续时间折算成指定的单位(向下取整)。
    Xxx:表示 DaysHoursMinutesMillis

  • toXxxPart()()JDK9,只提取格式化后该单位对应的“零头”部分。
    Xxx:表示 HoursMinutesSeconds

java
// 构造一个 65 分钟的跨度
Duration duration = Duration.ofMinutes(65);

// --- Java 8 的折算方法 ---
System.out.println("总小时数: " + duration.toHours());   // 1
System.out.println("总分钟数: " + duration.toMinutes()); // 65
System.out.println("总毫秒数: " + duration.toMillis());  // 3900000

// --- Java 9+ 的零头提取方法 (极其适合做 UI 倒计时显示) ---
// 65 分钟 = 1 小时 5 分钟
System.out.println("小时部分: " + duration.toHoursPart());   // 1
System.out.println("分钟部分: " + duration.toMinutesPart()); // 5 (看!不再是 65 了)

加减乘除

Duration 支持极其丰富的数学运算,甚至可以除以另一个 Duration

java
Duration d1 = Duration.ofMinutes(10);
Duration d2 = Duration.ofSeconds(30);

// 1. 加减运算
Duration sum = d1.plus(d2);  // 10分30秒
Duration diff = d1.minusMinutes(2); // 8分钟

// 2. 乘除运算
Duration doubled = d1.multipliedBy(2); // 20分钟

// 3. 跨度相除 (极其硬核:计算 d1 是 d2 的多少倍)
long ratio = d1.dividedBy(d2); 
System.out.println("10分钟是30秒的多少倍: " + ratio); // 20倍

状态与极值

常用于判断超时、或者比较两个时间谁先谁后。

  • isNegative():是否为负数(意味着 end 时间在 start 之前)。
  • isZero():是否为 0 秒。
  • abs():获取绝对值。如果不确定两个时间的先后顺序,但只关心绝对的时间差,用这个!
java
LocalTime time1 = LocalTime.of(10, 0);
LocalTime time2 = LocalTime.of(9, 0);

// time1 到 time2,时间是倒流的,所以跨度为负
Duration d = Duration.between(time1, time2);
System.out.println("是否为负: " + d.isNegative()); // true

// 获取绝对的时间差 (1 小时)
Duration absoluteDiff = d.abs();
System.out.println("绝对相差小时: " + absoluteDiff.toHours()); // 1

API:DateTimeFormatter

线程安全的格式化DateTimeFormatter

彻底告别 SimpleDateFormatDateTimeFormatter绝对线程安全的,可以直接定义为 public static final 的常量供全局使用。

内置格式

  • ISO_LOCAL_DATE:格式如 2023-10-25
  • ISO_LOCAL_TIME:格式如 14:30:15.123
  • ISO_LOCAL_DATE_TIME:格式如 2023-10-25T14:30:15 (带 T 分隔符)
  • BASIC_ISO_DATE:格式如 20231025 (没有任何分隔符,极其适合做批次号或文件名)

常用方法

  • static DateTimeFormatter ofPattern()(String pattern),使用系统默认语言环境创建格式器。

  • static DateTimeFormatter ofPattern()(String pattern, Locale locale)极其重要!
    如果你要解析含有英文单词(如 "Oct", "Monday")的时间字符串,必须指定 Locale.USLocale.ENGLISH,否则在中文操作系统下会直接报错。

java
import java.time.format.DateTimeFormatter;

public class TimeUtil {
    // 线程安全,随便并发调用!
    public static final DateTimeFormatter STANDARD_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) {
        LocalDateTime now = LocalDateTime.now();

        // 1. 格式化 (对象 -> 字符串)
        String str = now.format(STANDARD_FORMAT);
        System.out.println(str);

        // 2. 解析 (字符串 -> 对象)
        String input = "2023-10-25 14:30:00";
        LocalDateTime parsed = LocalDateTime.parse(input, STANDARD_FORMAT);

        // 3. 🚨 解析带英文单词的特殊格式 (务必带上 Locale)
        String englishDate = "25-Oct-2023 14:30";
        DateTimeFormatter engFmt = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm", Locale.ENGLISH);

        LocalDateTime dt = LocalDateTime.parse(englishDate, engFmt);
    }
}